// MutlipartFormData extracted from [Alamofire](https://github.com/Alamofire/Alamofire/blob/master/Source/Features/MultipartFormData.swift) for using as standalone.

//
//  MultipartFormData.swift
//
//  Copyright (c) 2014-2018 Alamofire Software Foundation (http://alamofire.org/)
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
//

import Foundation
import HTTPTypes

#if canImport(MobileCoreServices)
  import MobileCoreServices
#elseif canImport(CoreServices)
  import CoreServices
#endif

/// Constructs `multipart/form-data` for uploads within an HTTP or HTTPS body. There are currently two ways to encode
/// multipart form data. The first way is to encode the data directly in memory. This is very efficient, but can lead
/// to memory issues if the dataset is too large. The second way is designed for larger datasets and will write all the
/// data to a single file on disk with all the proper boundary segmentation. The second approach MUST be used for
/// larger datasets such as video content, otherwise your app may run out of memory when trying to encode the dataset.
///
/// For more information on `multipart/form-data` in general, please refer to the RFC-2388 and RFC-2045 specs as well
/// and the w3 form documentation.
///
/// - https://www.ietf.org/rfc/rfc2388.txt
/// - https://www.ietf.org/rfc/rfc2045.txt
/// - https://www.w3.org/TR/html401/interact/forms.html#h-17.13
class MultipartFormData {
  // MARK: - Helper Types

  enum EncodingCharacters {
    static let crlf = "\r\n"
  }

  enum BoundaryGenerator {
    enum BoundaryType {
      case initial, encapsulated, final
    }

    static func randomBoundary() -> String {
      let first = UInt32.random(in: UInt32.min...UInt32.max)
      let second = UInt32.random(in: UInt32.min...UInt32.max)

      return String(format: "alamofire.boundary.%08x%08x", first, second)
    }

    static func boundaryData(forBoundaryType boundaryType: BoundaryType, boundary: String) -> Data {
      let boundaryText =
        switch boundaryType {
        case .initial:
          "--\(boundary)\(EncodingCharacters.crlf)"
        case .encapsulated:
          "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)"
        case .final:
          "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)"
        }

      return Data(boundaryText.utf8)
    }
  }

  class BodyPart {
    let headers: HTTPFields
    let bodyStream: InputStream
    let bodyContentLength: UInt64
    var hasInitialBoundary = false
    var hasFinalBoundary = false

    init(headers: HTTPFields, bodyStream: InputStream, bodyContentLength: UInt64) {
      self.headers = headers
      self.bodyStream = bodyStream
      self.bodyContentLength = bodyContentLength
    }
  }

  // MARK: - Properties

  /// Default memory threshold used when encoding `MultipartFormData`, in bytes.
  static let encodingMemoryThreshold: UInt64 = 10_000_000

  /// The `Content-Type` header value containing the boundary used to generate the `multipart/form-data`.
  open lazy var contentType: String = "multipart/form-data; boundary=\(self.boundary)"

  /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries.
  var contentLength: UInt64 { bodyParts.reduce(0) { $0 + $1.bodyContentLength } }

  /// The boundary used to separate the body parts in the encoded form data.
  let boundary: String

  let fileManager: FileManager

  private var bodyParts: [BodyPart]
  private var bodyPartError: MultipartFormDataError?
  private let streamBufferSize: Int

  // MARK: - Lifecycle

  /// Creates an instance.
  ///
  /// - Parameters:
  ///   - fileManager: `FileManager` to use for file operations, if needed.
  ///   - boundary: Boundary `String` used to separate body parts.
  init(fileManager: FileManager = .default, boundary: String? = nil) {
    self.fileManager = fileManager
    self.boundary = boundary ?? BoundaryGenerator.randomBoundary()
    bodyParts = []

    //
    // The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more
    // information, please refer to the following article:
    //   - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html
    //
    streamBufferSize = 1024
  }

  // MARK: - Body Parts

  /// Creates a body part from the data and appends it to the instance.
  ///
  /// The body part data will be encoded using the following format:
  ///
  /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header)
  /// - `Content-Type: #{mimeType}` (HTTP Header)
  /// - Encoded file data
  /// - Multipart form boundary
  ///
  /// - Parameters:
  ///   - data:     `Data` to encoding into the instance.
  ///   - name:     Name to associate with the `Data` in the `Content-Disposition` HTTP header.
  ///   - fileName: Filename to associate with the `Data` in the `Content-Disposition` HTTP header.
  ///   - mimeType: MIME type to associate with the data in the `Content-Type` HTTP header.
  func append(
    _ data: Data, withName name: String, fileName: String? = nil, mimeType: String? = nil
  ) {
    let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
  }

  /// Creates a body part from the file and appends it to the instance.
  ///
  /// The body part data will be encoded using the following format:
  ///
  /// - `Content-Disposition: form-data; name=#{name}; filename=#{generated filename}` (HTTP Header)
  /// - `Content-Type: #{generated mimeType}` (HTTP Header)
  /// - Encoded file data
  /// - Multipart form boundary
  ///
  /// The filename in the `Content-Disposition` HTTP header is generated from the last path component of the
  /// `fileURL`. The `Content-Type` HTTP header MIME type is generated by mapping the `fileURL` extension to the
  /// system associated MIME type.
  ///
  /// - Parameters:
  ///   - fileURL: `URL` of the file whose content will be encoded into the instance.
  ///   - name:    Name to associate with the file content in the `Content-Disposition` HTTP header.
  func append(_ fileURL: URL, withName name: String) {
    let fileName = fileURL.lastPathComponent
    let pathExtension = fileURL.pathExtension

    if !fileName.isEmpty, !pathExtension.isEmpty {
      let mime = MultipartFormData.mimeType(forPathExtension: pathExtension)
      append(fileURL, withName: name, fileName: fileName, mimeType: mime)
    } else {
      setBodyPartError(.bodyPartFilenameInvalid(in: fileURL))
    }
  }

  /// Creates a body part from the file and appends it to the instance.
  ///
  /// The body part data will be encoded using the following format:
  ///
  /// - Content-Disposition: form-data; name=#{name}; filename=#{filename} (HTTP Header)
  /// - Content-Type: #{mimeType} (HTTP Header)
  /// - Encoded file data
  /// - Multipart form boundary
  ///
  /// - Parameters:
  ///   - fileURL:  `URL` of the file whose content will be encoded into the instance.
  ///   - name:     Name to associate with the file content in the `Content-Disposition` HTTP header.
  ///   - fileName: Filename to associate with the file content in the `Content-Disposition` HTTP header.
  ///   - mimeType: MIME type to associate with the file content in the `Content-Type` HTTP header.
  func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) {
    let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)

    //============================================================
    //                 Check 1 - is file URL?
    //============================================================

    guard fileURL.isFileURL else {
      setBodyPartError(.bodyPartURLInvalid(url: fileURL))
      return
    }

    //============================================================
    //              Check 2 - is file URL reachable?
    //============================================================

    #if !(os(Linux) || os(Windows) || os(Android))
      do {
        let isReachable = try fileURL.checkPromisedItemIsReachable()
        guard isReachable else {
          setBodyPartError(.bodyPartFileNotReachable(at: fileURL))
          return
        }
      } catch {
        setBodyPartError(.bodyPartFileNotReachableWithError(atURL: fileURL, error: error))
        return
      }
    #endif

    //============================================================
    //            Check 3 - is file URL a directory?
    //============================================================

    var isDirectory: ObjCBool = false
    let path = fileURL.path

    guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue
    else {
      setBodyPartError(.bodyPartFileIsDirectory(at: fileURL))
      return
    }

    //============================================================
    //          Check 4 - can the file size be extracted?
    //============================================================

    let bodyContentLength: UInt64

    do {
      guard let fileSize = try fileManager.attributesOfItem(atPath: path)[.size] as? NSNumber else {
        setBodyPartError(.bodyPartFileSizeNotAvailable(at: fileURL))
        return
      }

      bodyContentLength = fileSize.uint64Value
    } catch {
      setBodyPartError(.bodyPartFileSizeQueryFailedWithError(forURL: fileURL, error: error))
      return
    }

    //============================================================
    //       Check 5 - can a stream be created from file URL?
    //============================================================

    guard let stream = InputStream(url: fileURL) else {
      setBodyPartError(.bodyPartInputStreamCreationFailed(for: fileURL))
      return
    }

    append(stream, withLength: bodyContentLength, headers: headers)
  }

  /// Creates a body part from the stream and appends it to the instance.
  ///
  /// The body part data will be encoded using the following format:
  ///
  /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTP Header)
  /// - `Content-Type: #{mimeType}` (HTTP Header)
  /// - Encoded stream data
  /// - Multipart form boundary
  ///
  /// - Parameters:
  ///   - stream:   `InputStream` to encode into the instance.
  ///   - length:   Length, in bytes, of the stream.
  ///   - name:     Name to associate with the stream content in the `Content-Disposition` HTTP header.
  ///   - fileName: Filename to associate with the stream content in the `Content-Disposition` HTTP header.
  ///   - mimeType: MIME type to associate with the stream content in the `Content-Type` HTTP header.
  func append(
    _ stream: InputStream,
    withLength length: UInt64,
    name: String,
    fileName: String,
    mimeType: String
  ) {
    let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
    append(stream, withLength: length, headers: headers)
  }

  /// Creates a body part with the stream, length, and headers and appends it to the instance.
  ///
  /// The body part data will be encoded using the following format:
  ///
  /// - HTTP headers
  /// - Encoded stream data
  /// - Multipart form boundary
  ///
  /// - Parameters:
  ///   - stream:  `InputStream` to encode into the instance.
  ///   - length:  Length, in bytes, of the stream.
  ///   - headers: `HTTPHeaders` for the body part.
  func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPFields) {
    let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length)
    bodyParts.append(bodyPart)
  }

  // MARK: - Data Encoding

  /// Encodes all appended body parts into a single `Data` value.
  ///
  /// - Note: This method will load all the appended body parts into memory all at the same time. This method should
  ///         only be used when the encoded data will have a small memory footprint. For large data cases, please use
  ///         the `writeEncodedData(to:))` method.
  ///
  /// - Returns: The encoded `Data`, if encoding is successful.
  /// - Throws:  An `AFError` if encoding encounters an error.
  func encode() throws -> Data {
    if let bodyPartError {
      throw bodyPartError
    }

    var encoded = Data()

    bodyParts.first?.hasInitialBoundary = true
    bodyParts.last?.hasFinalBoundary = true

    for bodyPart in bodyParts {
      let encodedData = try encode(bodyPart)
      encoded.append(encodedData)
    }

    return encoded
  }

  /// Writes all appended body parts to the given file `URL`.
  ///
  /// This process is facilitated by reading and writing with input and output streams, respectively. Thus,
  /// this approach is very memory efficient and should be used for large body part data.
  ///
  /// - Parameter fileURL: File `URL` to which to write the form data.
  /// - Throws:            An `AFError` if encoding encounters an error.
  func writeEncodedData(to fileURL: URL) throws {
    if let bodyPartError {
      throw bodyPartError
    }

    if fileManager.fileExists(atPath: fileURL.path) {
      throw MultipartFormDataError.outputStreamFileAlreadyExists(at: fileURL)
    } else if !fileURL.isFileURL {
      throw MultipartFormDataError.outputStreamURLInvalid(url: fileURL)
    }

    guard let outputStream = OutputStream(url: fileURL, append: false) else {
      throw MultipartFormDataError.outputStreamCreationFailed(for: fileURL)
    }

    outputStream.open()
    defer { outputStream.close() }

    bodyParts.first?.hasInitialBoundary = true
    bodyParts.last?.hasFinalBoundary = true

    for bodyPart in bodyParts {
      try write(bodyPart, to: outputStream)
    }
  }

  // MARK: - Private - Body Part Encoding

  private func encode(_ bodyPart: BodyPart) throws -> Data {
    var encoded = Data()

    let initialData =
      bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
    encoded.append(initialData)

    let headerData = encodeHeaders(for: bodyPart)
    encoded.append(headerData)

    let bodyStreamData = try encodeBodyStream(for: bodyPart)
    encoded.append(bodyStreamData)

    if bodyPart.hasFinalBoundary {
      encoded.append(finalBoundaryData())
    }

    return encoded
  }

  private func encodeHeaders(for bodyPart: BodyPart) -> Data {
    let headerText =
      bodyPart.headers.map { "\($0.name): \($0.value)\(EncodingCharacters.crlf)" }
      .joined()
      + EncodingCharacters.crlf

    return Data(headerText.utf8)
  }

  private func encodeBodyStream(for bodyPart: BodyPart) throws -> Data {
    let inputStream = bodyPart.bodyStream
    inputStream.open()
    defer { inputStream.close() }

    var encoded = Data()

    while inputStream.hasBytesAvailable {
      var buffer = [UInt8](repeating: 0, count: streamBufferSize)
      let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize)

      if let error = inputStream.streamError {
        throw MultipartFormDataError.inputStreamReadFailed(error: error)
      }

      if bytesRead > 0 {
        encoded.append(buffer, count: bytesRead)
      } else {
        break
      }
    }

    guard UInt64(encoded.count) == bodyPart.bodyContentLength else {
      let error = MultipartFormDataError.UnexpectedInputStreamLength(
        bytesExpected: bodyPart.bodyContentLength,
        bytesRead: UInt64(encoded.count)
      )
      throw MultipartFormDataError.inputStreamReadFailed(error: error)
    }

    return encoded
  }

  // MARK: - Private - Writing Body Part to Output Stream

  private func write(_ bodyPart: BodyPart, to outputStream: OutputStream) throws {
    try writeInitialBoundaryData(for: bodyPart, to: outputStream)
    try writeHeaderData(for: bodyPart, to: outputStream)
    try writeBodyStream(for: bodyPart, to: outputStream)
    try writeFinalBoundaryData(for: bodyPart, to: outputStream)
  }

  private func writeInitialBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream)
    throws
  {
    let initialData =
      bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
    return try write(initialData, to: outputStream)
  }

  private func writeHeaderData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
    let headerData = encodeHeaders(for: bodyPart)
    return try write(headerData, to: outputStream)
  }

  private func writeBodyStream(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
    let inputStream = bodyPart.bodyStream

    inputStream.open()
    defer { inputStream.close() }

    var bytesLeftToRead = bodyPart.bodyContentLength
    while inputStream.hasBytesAvailable, bytesLeftToRead > 0 {
      let bufferSize = min(streamBufferSize, Int(bytesLeftToRead))
      var buffer = [UInt8](repeating: 0, count: bufferSize)
      let bytesRead = inputStream.read(&buffer, maxLength: bufferSize)

      if let streamError = inputStream.streamError {
        throw MultipartFormDataError.inputStreamReadFailed(error: streamError)
      }

      if bytesRead > 0 {
        if buffer.count != bytesRead {
          buffer = Array(buffer[0..<bytesRead])
        }

        try write(&buffer, to: outputStream)
        bytesLeftToRead -= UInt64(bytesRead)
      } else {
        break
      }
    }
  }

  private func writeFinalBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws
  {
    if bodyPart.hasFinalBoundary {
      try write(finalBoundaryData(), to: outputStream)
    }
  }

  // MARK: - Private - Writing Buffered Data to Output Stream

  private func write(_ data: Data, to outputStream: OutputStream) throws {
    var buffer = [UInt8](repeating: 0, count: data.count)
    data.copyBytes(to: &buffer, count: data.count)

    return try write(&buffer, to: outputStream)
  }

  private func write(_ buffer: inout [UInt8], to outputStream: OutputStream) throws {
    var bytesToWrite = buffer.count

    while bytesToWrite > 0, outputStream.hasSpaceAvailable {
      let bytesWritten = outputStream.write(buffer, maxLength: bytesToWrite)

      if let error = outputStream.streamError {
        throw MultipartFormDataError.outputStreamWriteFailed(error: error)
      }

      bytesToWrite -= bytesWritten

      if bytesToWrite > 0 {
        buffer = Array(buffer[bytesWritten..<buffer.count])
      }
    }
  }

  // MARK: - Private - Content Headers

  private func contentHeaders(
    withName name: String, fileName: String? = nil, mimeType: String? = nil
  ) -> HTTPFields {
    var disposition = "form-data; name=\"\(name)\""
    if let fileName { disposition += "; filename=\"\(fileName)\"" }

    var headers: HTTPFields = [.contentDisposition: disposition]
    if let mimeType { headers[.contentType] = mimeType }

    return headers
  }

  // MARK: - Private - Boundary Encoding

  private func initialBoundaryData() -> Data {
    BoundaryGenerator.boundaryData(forBoundaryType: .initial, boundary: boundary)
  }

  private func encapsulatedBoundaryData() -> Data {
    BoundaryGenerator.boundaryData(forBoundaryType: .encapsulated, boundary: boundary)
  }

  private func finalBoundaryData() -> Data {
    BoundaryGenerator.boundaryData(forBoundaryType: .final, boundary: boundary)
  }

  // MARK: - Private - Errors

  private func setBodyPartError(_ error: MultipartFormDataError) {
    guard bodyPartError == nil else { return }
    bodyPartError = error
  }
}

#if canImport(UniformTypeIdentifiers)
  import UniformTypeIdentifiers

  extension MultipartFormData {
    // MARK: - Private - Mime Type

    static func mimeType(forPathExtension pathExtension: String) -> String {
      #if swift(>=5.9)
        if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) {
          return UTType(filenameExtension: pathExtension)?.preferredMIMEType
            ?? "application/octet-stream"
        } else {
          if let id = UTTypeCreatePreferredIdentifierForTag(
            kUTTagClassFilenameExtension, pathExtension as CFString, nil
          )?.takeRetainedValue(),
            let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?
              .takeRetainedValue()
          {
            return contentType as String
          }

          return "application/octet-stream"
        }
      #else
        if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) {
          return UTType(filenameExtension: pathExtension)?.preferredMIMEType
            ?? "application/octet-stream"
        } else {
          if let id = UTTypeCreatePreferredIdentifierForTag(
            kUTTagClassFilenameExtension, pathExtension as CFString, nil
          )?.takeRetainedValue(),
            let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?
              .takeRetainedValue()
          {
            return contentType as String
          }

          return "application/octet-stream"
        }
      #endif
    }
  }

#else

  extension MultipartFormData {
    // MARK: - Private - Mime Type

    static func mimeType(forPathExtension pathExtension: String) -> String {
      #if canImport(CoreServices) || canImport(MobileCoreServices)
        if let id = UTTypeCreatePreferredIdentifierForTag(
          kUTTagClassFilenameExtension, pathExtension as CFString, nil
        )?.takeRetainedValue(),
          let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?
            .takeRetainedValue()
        {
          return contentType as String
        }
      #endif

      return "application/octet-stream"
    }
  }

#endif

enum MultipartFormDataError: Error {
  case bodyPartURLInvalid(url: URL)
  case bodyPartFilenameInvalid(in: URL)
  case bodyPartFileNotReachable(at: URL)
  case bodyPartFileNotReachableWithError(atURL: URL, error: any Error)
  case bodyPartFileIsDirectory(at: URL)
  case bodyPartFileSizeNotAvailable(at: URL)
  case bodyPartFileSizeQueryFailedWithError(forURL: URL, error: any Error)
  case bodyPartInputStreamCreationFailed(for: URL)
  case outputStreamFileAlreadyExists(at: URL)
  case outputStreamURLInvalid(url: URL)
  case outputStreamCreationFailed(for: URL)
  case inputStreamReadFailed(error: any Error)
  case outputStreamWriteFailed(error: any Error)

  struct UnexpectedInputStreamLength: Error {
    let bytesExpected: UInt64
    let bytesRead: UInt64
  }

  var underlyingError: (any Error)? {
    switch self {
    case let .bodyPartFileNotReachableWithError(_, error),
      let .bodyPartFileSizeQueryFailedWithError(_, error),
      let .inputStreamReadFailed(error),
      let .outputStreamWriteFailed(error):
      error

    case .bodyPartURLInvalid,
      .bodyPartFilenameInvalid,
      .bodyPartFileNotReachable,
      .bodyPartFileIsDirectory,
      .bodyPartFileSizeNotAvailable,
      .bodyPartInputStreamCreationFailed,
      .outputStreamFileAlreadyExists,
      .outputStreamURLInvalid,
      .outputStreamCreationFailed:
      nil
    }
  }

  var url: URL? {
    switch self {
    case let .bodyPartURLInvalid(url),
      let .bodyPartFilenameInvalid(url),
      let .bodyPartFileNotReachable(url),
      let .bodyPartFileNotReachableWithError(url, _),
      let .bodyPartFileIsDirectory(url),
      let .bodyPartFileSizeNotAvailable(url),
      let .bodyPartFileSizeQueryFailedWithError(url, _),
      let .bodyPartInputStreamCreationFailed(url),
      let .outputStreamFileAlreadyExists(url),
      let .outputStreamURLInvalid(url),
      let .outputStreamCreationFailed(url):
      url

    case .inputStreamReadFailed, .outputStreamWriteFailed:
      nil
    }
  }
}
